📖

银花人生

AI 记者渐进式采访,挖掘你的人生故事,
生成属于你的生命之花。

🎙️

AI 深度访谈

像专业记者一样,智能引导你回顾人生每个阶段

🃏

记忆卡片

每个故事自动生成精美卡片,可配老照片

📚

人生回忆录

一键生成按章节排版的专属回忆录

支持语音输入,像聊天一样轻松

🌸 你的生命之花

完成 AI 访谈后,这里将绽放你的生命之花

🤖
AI 访谈记者 · 小回
正在聆听你的故事...

📜 故事时间线

你的人生,以故事的方式展开

人生回忆录

—— 银花人生 · AI 出品 ——

📝

完成访谈后,这里会自动生成你的专属回忆录

按住按钮说话,松手自动发送给家人

💬 消息记录

还没有语音消息
👆 长辈按住大红按钮说话,家人点击快捷回复

🕰️ 声音时光机

每周一个问题,记录人生声音

本周话题
`; const blob = new Blob(['\ufeff' + html], { type: 'application/msword;charset=UTF-8' }); const url = URL.createObjectURL(blob); const a = document.createElement('a'); a.href = url; a.download = authorName + '的人生回忆录.doc'; document.body.appendChild(a); a.click(); document.body.removeChild(a); URL.revokeObjectURL(url); } // ═══════════════════════════════════════════ // 生命之花 · Life Flower // ═══════════════════════════════════════════ // ── 八种花型定义 ── const FLOWER_TYPES = { rose: { name:'玫瑰', emoji:'🌹', tagline:'情深似海,热烈盛放', shape:'rounded', petalCount:8, petalWidth:36, petalLength:118, curve:0.82, layers:2, innerScale:0.48, layerOff:0.24, colors:[[200,40,60],[225,70,85],[170,25,40]], cColors:['#fef0d8','#e8c060','#c89830'], cSize:26, cType:'sunburst', keywords:'爱情浪漫心动热烈激情结婚恋人相爱初恋甜蜜深爱拥抱温暖回忆缘分感动刻骨铭心' }, jasmine: { name:'茉莉', emoji:'🤍', tagline:'素雅芬芳,岁月静好', shape:'pointed', petalCount:8, petalWidth:15, petalLength:95, curve:0.42, layers:1, innerScale:0, layerOff:0, colors:[[252,248,240],[248,242,232],[240,234,222]], cColors:['#fef8e0','#f0d860','#e0b020'], cSize:30, cType:'simple', keywords:'平静简单幸福安宁温柔慈家田园朴素知足清淡静慢陪伴安详普通小日子安稳' }, peony: { name:'牡丹', emoji:'💮', tagline:'雍容华贵,一世风华', shape:'ruffled', petalCount:12, petalWidth:26, petalLength:112, curve:0.72, layers:3, innerScale:0.42, layerOff:0.17, colors:[[230,80,105],[248,130,150],[200,55,80]], cColors:['#fef7ee','#f5d895','#c89830'], cSize:20, cType:'sunburst', keywords:'成就事业成功奋斗努力辉煌荣耀骄傲自豪领导创业拼搏功赢公司贡献大成绩突出' }, sunflower: { name:'向日葵', emoji:'🌻', tagline:'心向阳光,温暖如初', shape:'thin', petalCount:16, petalWidth:7, petalLength:95, curve:0.28, layers:2, innerScale:0.62, layerOff:0.1, colors:[[255,200,50],[255,230,100],[240,170,30]], cColors:['#f5e0b0','#b8860b','#5c3a0a'], cSize:38, cType:'large', keywords:'开心快乐乐观阳光笑感谢感恩美好希望积极向上幸福喜欢欢乐正能量晴朗' }, lotus: { name:'荷花', emoji:'🪷', tagline:'出淤泥不染,濯清涟不妖', shape:'pointed', petalCount:10, petalWidth:20, petalLength:112, curve:0.48, layers:2, innerScale:0.52, layerOff:0.18, colors:[[255,175,175],[255,218,218],[245,148,148]], cColors:['#fef8e0','#e8d060','#c8a020'], cSize:28, cType:'flat', keywords:'善良正直诚实清白原则信仰良心道德奉献无私帮助善义纯洁干净真诚高尚品格' }, plum: { name:'梅花', emoji:'🌸', tagline:'凌寒独自开,暗香浮动', shape:'rounded', petalCount:5, petalWidth:34, petalLength:98, curve:0.6, layers:1, innerScale:0, layerOff:0, colors:[[240,130,150],[250,170,185],[220,100,125]], cColors:['#fef5e8','#f0d080','#d4a030'], cSize:24, cType:'simple', keywords:'困难苦难熬坚持撑苦穷克服坚强勇敢挺走下去不容易累痛硬扛不屈不折咬牙' }, chrysanthemum: { name:'菊花', emoji:'🌼', tagline:'采菊东篱,悠然此生', shape:'thin', petalCount:20, petalWidth:4.5, petalLength:100, curve:0.22, layers:2, innerScale:0.48, layerOff:0.07, colors:[[255,215,0],[255,240,150],[230,180,0]], cColors:['#fef8e0','#e8c860','#c89820'], cSize:26, cType:'sunburst', keywords:'智慧经验人生道理悟明白懂看透哲学反思总结教训教智理通透成熟岁月沉淀' }, chinaRose: { name:'月季', emoji:'🌺', tagline:'四季常开,平凡而伟大', shape:'rounded', petalCount:8, petalWidth:30, petalLength:106, curve:0.66, layers:2, innerScale:0.46, layerOff:0.22, colors:[[235,105,125],[245,145,160],[210,80,100]], cColors:['#fef3e5','#e8c060','#c89830'], cSize:24, cType:'sunburst', keywords:'平凡日子日常工作家庭孩子照顾操劳忙碌辛苦踏实责任养供持守护柴米油盐家常' }, }; // ── 花语判定引擎 ── function determineFlowerType() { const allText = memories.map(m => (m.answer||'')).join(''); if (!allText.trim()) return 'rose'; let best = 'rose', bestScore = -1; for (const [key, ft] of Object.entries(FLOWER_TYPES)) { let s = 0; for (let i = 0; i < ft.keywords.length; i++) { const re = new RegExp(ft.keywords[i], 'g'); const m = allText.match(re); if (m) s += m.length; } if (s > bestScore) { bestScore = s; best = key; } } if (bestScore === 0) { if (/爱|情|恋/.test(allText)) return 'rose'; if (/工作|事业|成功/.test(allText)) return 'peony'; if (/困难|苦|熬/.test(allText)) return 'plum'; if (/快乐|笑|开心/.test(allText)) return 'sunflower'; return 'chinaRose'; } return best; } // ── Unsplash 花朵图片获取(带缓存) ── async function fetchFlowerImage(flowerKey) { const ft = FLOWER_TYPES[flowerKey]; if (!ft) return null; // 1. 检查内存缓存(24小时有效) const cached = flowerImageCache[flowerKey]; if (cached && cached.url && (Date.now() - cached.ts < 86400000)) { return cached; } // 2. 没配 API Key → 降级 if (!UNSPLASH_CONFIG.accessKey) return null; // 3. 请求 Unsplash try { const query = encodeURIComponent(ft.name + ' flower'); const url = `${UNSPLASH_CONFIG.endpoint}?query=${query}&orientation=squarish&per_page=3&client_id=${UNSPLASH_CONFIG.accessKey}`; const resp = await fetch(url); if (!resp.ok) { console.warn('Unsplash 请求失败:', resp.status); return null; } const data = await resp.json(); if (!data.results || data.results.length === 0) return null; // 取第一张 const photo = data.results[0]; const result = { url: photo.urls.regular, thumb: photo.urls.thumb, author: photo.user.name, authorLink: photo.user.links.html + '?utm_source=银花人生&utm_medium=referral', ts: Date.now(), }; // 写入缓存 flowerImageCache[flowerKey] = result; saveFlowerCache(); return result; } catch (e) { console.warn('Unsplash 请求异常:', e.message); return null; } } // ── 生命之花主渲染(Unsplash 照片 + Canvas 生长叠加) ── function renderLifeFlower() { const photoEl = document.getElementById('flowerPhoto'); const cssFlower = document.getElementById('cssFlower'); const creditEl = document.getElementById('flowerCredit'); const canvas = document.getElementById('lifeFlowerCanvas'); if (!cssFlower || !canvas) return; const ctx = canvas.getContext('2d'); const cx = 160, cy = 160; ctx.clearRect(0, 0, 320, 320); const hasAny = memories.length > 0; const flowerKey = determineFlowerType(); const ft = FLOWER_TYPES[flowerKey]; // ── 花瓣生长数据 ── const petals = interviewPlan.map(p => { const m = memories.find(m => m.id === p.id); return { richness: m ? Math.min(1, (m.answer||'').length / 100) : 0.06, hasVoice: !!(m&&m.voiceAudio) }; }); // ── 先设好 CSS 花作为降级/加载背景 ── cssFlower.className = 'css-flower css-flower-' + flowerKey; cssFlower.style.display = ''; if (photoEl) { photoEl.style.display = 'none'; photoEl.src = ''; } if (creditEl) creditEl.style.display = 'none'; if (!hasAny) { // 空状态 ctx.fillStyle = '#c8b898'; ctx.font = '13px "PingFang SC","Microsoft YaHei",sans-serif'; ctx.textAlign = 'center'; ctx.textBaseline = 'middle'; ctx.fillText('等待绽放', cx, cy); } else { // ── 绘制生长光环 ── const ringR = 115; for (let i = 0; i < 8; i++) { const pt = petals[i]; const angle = (i / 8) * Math.PI * 2 - Math.PI / 2; const dx = cx + Math.cos(angle) * ringR; const dy = cy + Math.sin(angle) * ringR; const size = 4 + pt.richness * 9; const alpha = 0.28 + pt.richness * 0.68; ctx.beginPath(); ctx.arc(dx, dy, size + 5, 0, Math.PI * 2); const glowGrad = ctx.createRadialGradient(dx, dy, size * 0.3, dx, dy, size + 5); glowGrad.addColorStop(0, `rgba(255,235,180,${alpha * 0.7})`); glowGrad.addColorStop(1, 'rgba(255,235,180,0)'); ctx.fillStyle = glowGrad; ctx.fill(); ctx.beginPath(); ctx.arc(dx, dy, size, 0, Math.PI * 2); const dotGrad = ctx.createRadialGradient(dx - 1.5, dy - 1.5, 0, dx, dy, size); dotGrad.addColorStop(0, `rgba(255,248,225,${alpha})`); dotGrad.addColorStop(0.45, `rgba(235,195,115,${alpha})`); dotGrad.addColorStop(1, `rgba(180,140,60,${alpha * 0.4})`); ctx.fillStyle = dotGrad; ctx.shadowColor = `rgba(210,170,90,${alpha * 0.55})`; ctx.shadowBlur = 8; ctx.fill(); ctx.shadowColor = 'transparent'; if (pt.hasVoice) { ctx.beginPath(); ctx.arc(dx, dy, size + 5, 0, Math.PI * 2); ctx.strokeStyle = 'rgba(255,225,130,0.75)'; ctx.lineWidth = 1.8; ctx.setLineDash([3, 2]); ctx.stroke(); ctx.setLineDash([]); } } // ── 中心名字(照片上叠加) ── const basic = memories.find(m => m.id === 'basic'); const name = basic ? extractName(basic.answer) : ''; if (name) { ctx.fillStyle = 'rgba(255,255,255,0.9)'; ctx.shadowColor = 'rgba(0,0,0,0.45)'; ctx.shadowBlur = 6; ctx.font = 'bold 16px "PingFang SC","Noto Serif SC","SimSun",serif'; ctx.textAlign = 'center'; ctx.textBaseline = 'middle'; ctx.fillText(name, cx, cy - 2); ctx.shadowColor = 'transparent'; ctx.font = '11px "PingFang SC","Microsoft YaHei",sans-serif'; ctx.fillStyle = 'rgba(255,255,255,0.75)'; ctx.shadowColor = 'rgba(0,0,0,0.3)'; ctx.shadowBlur = 4; ctx.fillText(ft.emoji + ' ' + ft.name, cx, cy + 20); ctx.shadowColor = 'transparent'; } } // ── 浮粒 ── for (let k = 0; k < 14; k++) { const pa = ((k * 137 + 42) % 100) / 100 * Math.PI * 2; const pr = 108 + ((k * 73 + 42) % 52); const ps = 1.2 + ((k * 47) % 3) * 0.4; ctx.beginPath(); ctx.arc(cx + Math.cos(pa) * pr, cy + Math.sin(pa) * pr, ps, 0, Math.PI * 2); ctx.fillStyle = `rgba(240,185,135,${0.1 + ((k * 137 + 42) % 100) / 700})`; ctx.fill(); } // ── UI 文字 ── const basic2 = memories.find(m => m.id === 'basic'); const name2 = basic2 ? extractName(basic2.answer) : ''; const tEl = document.getElementById('flowerTitle'); if (tEl) tEl.innerHTML = hasAny ? `${ft.emoji} ${name2 || '你'}的生命之花 · ${ft.name}` : '🌸 你的生命之花'; const sEl = document.getElementById('flowerSubtitle'); if (sEl) sEl.textContent = hasAny ? `「${ft.tagline}」—— 根据你的人生故事绽放` : '完成 AI 访谈后,这里将绽放你的生命之花'; const cEl = document.getElementById('flowerCaption'); if (cEl) cEl.textContent = hasAny ? '' : '完成 AI 访谈,每回答一个问题,花瓣就会绽放一分。'; // ── 异步加载真实照片 ── if (hasAny) { fetchFlowerImage(flowerKey).then(result => { if (result && result.url && photoEl) { // 真实照片就绪 → 隐藏 CSS 花,显示照片 photoEl.src = result.url; photoEl.style.display = ''; cssFlower.style.display = 'none'; // Unsplash 署名 if (creditEl && result.author) { creditEl.innerHTML = `Photo by ${result.author} / Unsplash`; creditEl.style.display = ''; } } }); } } // ── 页面切换时刷新花朵 ── const origSwitchPage2 = switchPage; switchPage = function(name) { origSwitchPage2(name); if (name === 'welcome') setTimeout(renderLifeFlower, 100); }; // ── Timeline ── function renderTimeline() { const container = document.getElementById('timelineContainer'); const empty = document.getElementById('timelineEmpty'); const sorted = [...memories].sort((a,b) => interviewPlan.findIndex(p=>p.id===a.id) - interviewPlan.findIndex(p=>p.id===b.id)); if (sorted.length === 0) { container.innerHTML=''; empty.style.display='block'; return; } empty.style.display = 'none'; container.innerHTML = sorted.map(m => `
${m.period}

${m.title}

${m.summary}

🏷️ ${m.keywords}
`).join(''); } // ── Memoir ── function renderMemoir() { const body = document.getElementById('memoirBody'); const empty = document.getElementById('memoirEmpty'); const content = document.getElementById('memoirContent'); const dlBtn = document.getElementById('memoirDownload'); const sorted = [...memories].sort((a,b) => interviewPlan.findIndex(p=>p.id===a.id) - interviewPlan.findIndex(p=>p.id===b.id)); if (sorted.length === 0) { empty.style.display = 'block'; content.style.display = 'none'; if (dlBtn) dlBtn.style.display = 'none'; return; } empty.style.display = 'none'; content.style.display = 'block'; if (dlBtn) dlBtn.style.display = 'block'; const basic = memories.find(m => m.id === 'basic'); const authorName = basic ? extractName(basic.answer) : '我'; document.getElementById('memoirAuthor').textContent = '—— ' + authorName + ' · 银花人生 ——'; body.innerHTML = sorted.map((m, i) => `

${i+1}. ${m.period} —— ${m.title}

${m.summary}

`).join(''); } function extractName(answer) { const m = answer.match(/(?:叫|是|我叫|我是)(.+?)(?:,|。|,|\.|\s|$)/); return m ? m[1].replace(/[的地得]/g,'').slice(0,6) : '我'; } // ── Page Switch Helper ── function switchPage(name) { document.querySelectorAll('.nav-tab').forEach(t => t.classList.remove('active')); const tab = document.querySelector(`.nav-tab[data-page="${name}"]`); if (tab) tab.classList.add('active'); document.querySelectorAll('.page').forEach(p => p.classList.remove('active')); const pageEl = document.getElementById('page-' + name); if (!pageEl) return; pageEl.classList.add('active'); if (name === 'timeline') renderTimeline(); if (name === 'memoir') renderMemoir(); if (name === 'lifeline') initLifeline(); } // ── Init ── updateSettingsBadge(); setTimeout(renderLifeFlower, 300); if (memories.length > 0 && memories.some(m => m.id === 'basic')) { currentStep = memories.length; document.getElementById('questionArea').innerHTML = `
👋 欢迎回来!
你之前已完成 ${currentStep} 个访谈问题。
现在可以继续未完成的访谈。
`; document.getElementById('answerArea').innerHTML = `
`; } // ═══════════════════════════════════════════ // 声命线 · Voice Lifeline // ═══════════════════════════════════════════ let lifelineRole = 'elder'; // 'elder' | 'family' let voiceMessages = (function() { try { return JSON.parse(localStorage.getItem('echoVoiceMsgs') || '[]'); } catch(e) { localStorage.removeItem('echoVoiceMsgs'); return []; } })(); let voiceArchives = (function() { try { return JSON.parse(localStorage.getItem('echoVoiceArchive') || '[]'); } catch(e) { localStorage.removeItem('echoVoiceArchive'); return []; } })(); let mediaRecorder = null; let audioChunks = []; let recordTimerInterval = null; let recordStartTime = 0; let currentAudio = null; let isTopicRecording = false; // 每周话题 const LIFE_QUESTIONS = [ '你年轻时最骄傲的一件事是什么?', '你和老伴是怎么认识的?还记得第一次见面吗?', '这辈子最难忘的一个瞬间是什么?', '有什么话想留给孙子长大后听?', '你觉得人生最重要的是什么?', '最想对年轻时的自己说的一句话?', '年轻时做过最勇敢的一件事是什么?', '如果时间能倒流,你会改变哪个选择?', ]; // 根据当前周数选择话题 function getWeeklyTopic() { const weekNum = Math.floor(Date.now() / (7 * 24 * 60 * 60 * 1000)); return LIFE_QUESTIONS[weekNum % LIFE_QUESTIONS.length]; } // ── 初始化声命线页面 ── function initLifeline() { renderVoiceMessages(); updateRoleUI(); document.getElementById('topicQuestion').textContent = getWeeklyTopic(); renderArchives(); } // ── 角色切换 ── function switchRole(role) { lifelineRole = role; updateRoleUI(); } function updateRoleUI() { document.querySelectorAll('.role-switch-btn').forEach(b => b.classList.remove('active')); const activeBtn = document.querySelector(`.role-switch-btn[data-role="${lifelineRole}"]`); if (activeBtn) activeBtn.classList.add('active'); const elderArea = document.getElementById('elderRecordArea'); const familyArea = document.getElementById('familyReplyArea'); if (lifelineRole === 'elder') { elderArea.style.display = 'block'; familyArea.style.display = 'none'; } else { elderArea.style.display = 'none'; familyArea.style.display = 'block'; } } // ═══════════════════════════════════════════ // 语音录制核心 // ═══════════════════════════════════════════ async function getMediaStream() { try { return await navigator.mediaDevices.getUserMedia({ audio: true }); } catch (err) { console.error('麦克风权限被拒绝:', err); alert('需要麦克风权限才能录音。请在浏览器设置中允许麦克风访问。'); return null; } } function startRecording(onStop) { if (mediaRecorder && mediaRecorder.state === 'recording') return; getMediaStream().then(stream => { if (!stream) return; audioChunks = []; const mimeType = MediaRecorder.isTypeSupported('audio/webm;codecs=opus') ? 'audio/webm;codecs=opus' : 'audio/webm'; mediaRecorder = new MediaRecorder(stream, { mimeType }); mediaRecorder.ondataavailable = e => { if (e.data.size > 0) audioChunks.push(e.data); }; mediaRecorder.onstop = () => { stream.getTracks().forEach(t => t.stop()); if (audioChunks.length === 0) return; const blob = new Blob(audioChunks, { type: mimeType }); const reader = new FileReader(); reader.onloadend = () => { const base64 = reader.result; if (onStop) onStop(base64, (Date.now() - recordStartTime) / 1000); }; reader.readAsDataURL(blob); }; mediaRecorder.start(); recordStartTime = Date.now(); startRecordTimer(); }); } function stopRecording() { if (mediaRecorder && mediaRecorder.state === 'recording') { mediaRecorder.stop(); stopRecordTimer(); } } function startRecordTimer() { const timerEl = document.getElementById('recordTimer'); if (timerEl) { timerEl.style.display = 'block'; timerEl.textContent = '00:00'; } recordTimerInterval = setInterval(() => { const elapsed = Math.floor((Date.now() - recordStartTime) / 1000); const min = String(Math.floor(elapsed / 60)).padStart(2, '0'); const sec = String(elapsed % 60).padStart(2, '0'); if (timerEl) timerEl.textContent = min + ':' + sec; // 最长60秒 if (elapsed >= 60) stopRecording(); }, 200); } function stopRecordTimer() { if (recordTimerInterval) { clearInterval(recordTimerInterval); recordTimerInterval = null; } const timerEl = document.getElementById('recordTimer'); if (timerEl) timerEl.style.display = 'none'; } // ═══════════════════════════════════════════ // 大红按钮:按住说话 // ═══════════════════════════════════════════ function startBigRecord(e) { if (lifelineRole !== 'elder') return; e.preventDefault(); if (mediaRecorder && mediaRecorder.state === 'recording') return; const btn = document.getElementById('bigRecordBtn'); const label = document.getElementById('recordBtnLabel'); btn.classList.add('recording'); label.textContent = '正在录音...'; startRecording((base64, duration) => { addVoiceMessage('长辈', 'sent', base64, duration); // 模拟家人自动回复 if (voiceMessages.length <= 2) { setTimeout(() => { addVoiceMessage('家人', 'received', null, 2, '听到了!爸妈注意身体~❤️'); }, 2000); } }); } function stopBigRecord(e) { if (lifelineRole !== 'elder') return; e.preventDefault(); const btn = document.getElementById('bigRecordBtn'); const label = document.getElementById('recordBtnLabel'); btn.classList.remove('recording'); label.textContent = '按住说话'; stopRecording(); } // ═══════════════════════════════════════════ // 家人模式:快捷回复 // ═══════════════════════════════════════════ let isQuickRecording = false; function startQuickRecord() { if (isQuickRecording) { stopQuickRecord(); return; } if (mediaRecorder && mediaRecorder.state === 'recording') return; isQuickRecording = true; const btn = document.getElementById('familyRecordBtn'); const status = document.getElementById('familyRecordStatus'); btn.textContent = '🔴 停止录音'; status.style.display = 'inline'; startRecording((base64, duration) => { addVoiceMessage('家人', 'sent', base64, duration); }); } function stopQuickRecord() { isQuickRecording = false; const btn = document.getElementById('familyRecordBtn'); const status = document.getElementById('familyRecordStatus'); btn.textContent = '🎤 语音回复爸妈'; status.style.display = 'none'; stopRecording(); } function quickReply(text) { addVoiceMessage('家人', 'sent', null, 0, text); } // ═══════════════════════════════════════════ // 声音时光机 · 话题录音 // ═══════════════════════════════════════════ function startTopicRecord() { if (isTopicRecording) { stopTopicRecord(); return; } if (mediaRecorder && mediaRecorder.state === 'recording') return; isTopicRecording = true; const btn = document.getElementById('topicRecordBtn'); const status = document.getElementById('topicRecordStatus'); btn.classList.add('recording'); btn.textContent = '🔴 停止录音'; status.style.display = 'inline'; startRecording((base64, duration) => { const topic = getWeeklyTopic(); const archive = { id: Date.now(), topic: topic, audio: base64, duration: duration, time: new Date().toISOString(), }; voiceArchives.unshift(archive); // 最多保留20条 if (voiceArchives.length > 20) voiceArchives = voiceArchives.slice(0, 20); localStorage.setItem('echoVoiceArchive', JSON.stringify(voiceArchives)); renderArchives(); }); } function stopTopicRecord() { isTopicRecording = false; const btn = document.getElementById('topicRecordBtn'); const status = document.getElementById('topicRecordStatus'); btn.classList.remove('recording'); btn.textContent = '🎙️ 录下你的回答'; status.style.display = 'none'; stopRecording(); } function renderArchives() { const container = document.getElementById('archiveList'); if (voiceArchives.length === 0) { container.innerHTML = '
还没有存档声音
👆 点击上方按钮,留下你的声音
'; return; } container.innerHTML = voiceArchives.map((a, i) => `
${a.topic}
${new Date(a.time).toLocaleDateString('zh-CN')} · ${formatDuration(a.duration)}
`).join(''); } function playArchiveAudio(id, btn) { const archive = voiceArchives.find(a => a.id === Number(id)); if (!archive || !archive.audio) return; stopAllAudio(); playBase64Audio(archive.audio, btn); } // ═══════════════════════════════════════════ // 语音消息管理 // ═══════════════════════════════════════════ function addVoiceMessage(from, direction, base64, duration, textFallback) { const msg = { id: Date.now(), from: from, direction: direction, audio: base64 || null, text: textFallback || null, duration: duration || 0, time: new Date().toISOString(), }; voiceMessages.push(msg); // 最多保留30条 if (voiceMessages.length > 30) voiceMessages = voiceMessages.slice(-30); saveVoiceMessages(); renderVoiceMessages(); } function saveVoiceMessages() { try { // base64 数据较大,压缩存储 const compact = voiceMessages.map(m => ({ id: m.id, from: m.from, direction: m.direction, audio: m.audio, text: m.text, duration: m.duration, time: m.time, })); localStorage.setItem('echoVoiceMsgs', JSON.stringify(compact)); } catch (e) { // localStorage 满了,清掉旧消息 console.warn('存储空间不足,清理旧消息'); voiceMessages = voiceMessages.slice(-10); const compact = voiceMessages.map(m => ({ id: m.id, from: m.from, direction: m.direction, audio: m.audio, text: m.text, duration: m.duration, time: m.time, })); localStorage.setItem('echoVoiceMsgs', JSON.stringify(compact)); } } function deleteVoiceMessage(id) { voiceMessages = voiceMessages.filter(m => m.id !== id); saveVoiceMessages(); renderVoiceMessages(); } function downloadVoiceMsg(id) { const m = voiceMessages.find(m => m.id === id); if (!m || !m.audio) { alert('该消息没有语音'); return; } downloadBase64File(m.audio, '声命线·' + m.from + '·' + new Date(m.time).toLocaleDateString('zh-CN') + '.webm'); } function downloadArchiveVoice(id) { const a = voiceArchives.find(a => a.id === Number(id)); if (!a || !a.audio) { alert('该存档没有语音'); return; } downloadBase64File(a.audio, '声音存档·' + a.topic.slice(0,10) + '.webm'); } function renderVoiceMessages() { const container = document.getElementById('voiceMsgList'); const noMsgs = document.getElementById('noMsgs'); if (!container) return; if (voiceMessages.length === 0) { container.innerHTML = ''; if (noMsgs) noMsgs.style.display = 'block'; return; } if (noMsgs) noMsgs.style.display = 'none'; container.innerHTML = voiceMessages.map((m, i) => { const cls = m.direction === 'sent' ? 'sent' : 'received'; const avatar = m.direction === 'sent' ? (m.from === '长辈' ? '👴' : '👨‍👩‍👧') : (m.from === '长辈' ? '👴' : '👨‍👩‍👧'); const roleLabel = m.from + (m.direction === 'sent' ? '· 发出' : '· 收到'); const isText = m.text && !m.audio; return `
${avatar}
${roleLabel}
${isText ? `
${m.text}
` : `
${formatDuration(m.duration)}
` }
${formatTime(m.time)}
`; }).join(''); } function playMessageAudio(id, btn) { const msg = voiceMessages.find(m => m.id === id); if (!msg || !msg.audio) return; stopAllAudio(); playBase64Audio(msg.audio, btn); } // ═══════════════════════════════════════════ // 音频播放 // ═══════════════════════════════════════════ function playBase64Audio(base64, btnEl) { if (currentAudio) { currentAudio.pause(); currentAudio = null; } document.querySelectorAll('.msg-play-btn.playing, .archive-play-btn.playing').forEach(b => b.classList.remove('playing')); const audio = new Audio(base64); currentAudio = audio; if (btnEl) { btnEl.textContent = '⏸'; btnEl.classList.add('playing'); } audio.play(); audio.onended = () => { if (btnEl) { btnEl.textContent = '▶️'; btnEl.classList.remove('playing'); } currentAudio = null; }; audio.onerror = () => { if (btnEl) { btnEl.textContent = '▶️'; btnEl.classList.remove('playing'); } currentAudio = null; }; } function stopAllAudio() { if (currentAudio) { currentAudio.pause(); currentAudio = null; } document.querySelectorAll('.msg-play-btn.playing, .archive-play-btn.playing').forEach(b => { b.classList.remove('playing'); b.textContent = '▶️'; }); } // ── 格式化工具 ── function formatDuration(sec) { if (!sec || sec <= 0) return '0:00'; const m = Math.floor(sec / 60); const s = Math.floor(sec % 60); return m + ':' + String(s).padStart(2, '0'); } function formatTime(iso) { const d = new Date(iso); const now = new Date(); const diff = now - d; if (diff < 60000) return '刚刚'; if (diff < 3600000) return Math.floor(diff / 60000) + '分钟前'; if (diff < 86400000) return Math.floor(diff / 3600000) + '小时前'; return d.toLocaleDateString('zh-CN') + ' ' + d.toLocaleTimeString('zh-CN', { hour: '2-digit', minute: '2-digit' }); } // ── 页面切换时停止所有音频 ── const origSwitchPage = switchPage; switchPage = function(name) { stopAllAudio(); if (mediaRecorder && mediaRecorder.state === 'recording') stopRecording(); stopRecordTimer(); isQuickRecording = false; isTopicRecording = false; origSwitchPage(name); };